Skip to content

fix(opencode): surface truncated turns instead of ending the loop - #40142

Open
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:truncation-finish
Open

fix(opencode): surface truncated turns instead of ending the loop#40142
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:truncation-finish

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 2, 2026

Copy link
Copy Markdown

Issue for this PR

Fixes #40146.

That issue is item 4 of the root-cause chain in #18108 (P1 on its checklist), filed separately because #18108 covers five interacting problems and this one is independently fixable.

Related: #38747 (V2-side truncated-stream recovery — same problem class, different layer), #29363 (the 32K OUTPUT_TOKEN_MAX clamp that makes truncation common — deliberately out of scope here).

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A turn that finishes length was classified as a normal completion. Two checks in the session loop exclude only "tool-calls" (and "unknown"), never "length", so a provider that truncated its output at the token limit ended the session exactly as though the model had chosen to stop.

That is #18108's P1, filed in March:

"length" is NOT in the exclusion list for modelFinished check, so the session loop breaks when the model is cut off mid-tool-call.

The failure is worst when the truncated turn produced nothing usable. A subagent on an OpenAI-compatible gateway spent its entire output budget inside the reasoning channel — parts were step-start + reasoning (129,961 chars) + step-finish, no text and no tool call, finish: "length" — and the task completed with status ok and empty output. From the caller's side that is indistinguishable from a subagent that had nothing to say. Nothing anywhere in src branched on "length".

The change: when a turn finishes length, branch on whether it produced anything worth continuing from.

Turn produced Behaviour
text or tool parts, and the previous turn did not also finish length continue — one more provider turn to finish emitting
reasoning only, or nothing usable set OutputLengthError, stop
text or tools, but the previous turn also finished length set OutputLengthError, stop

Three decisions worth explaining, since each has a plausible-looking alternative:

Continuation is bounded to exactly one attempt, with no counter. The bound reads lastAssistant.finish from history the loop already reloads each iteration. agent.steps could not be reused for this: it defaults to Infinity, and at the limit it only injects MAX_STEPS_PROMPT rather than breaking — it is a nudge, not a bound. An unbounded continuation would be worse than the original bug, since each iteration costs a full output budget.

Recoverability is decided by part presence, not token counts. Some gateways report reasoning inside the ordinary output count, so tokens.reasoning reads 0 on exactly the turns this targets. A token-based check would be vacuous where it matters most.

OutputLengthError is reused, not newly defined. It already exists in the schema, in core, in session/message-error.ts, is already a member of the assistant error union, and acp/service.ts already maps it to stopReason: "max_tokens". It had no producer anywhere in src — the plumbing was built for this case and never connected. This connects it.

The entry-gate change is load-bearing rather than cosmetic: the loop reloads history at the top of every iteration, so without excluding "length" there, the reloaded state (a truncated message with no tool calls) exits before the continuation turn can run. A reviewer confirmed this independently by reverting that hunk alone.

One behavioural note for reviewers: the entry gate also governs direct loop() callers. A session whose last assistant turn finished length and is resumed through the summarize endpoint will now take an additional provider turn where it previously stopped. For that path the caller has explicitly asked for compaction and resumption, so continuing seems right — but it is a real change and worth a second opinion.

The 32K OUTPUT_TOKEN_MAX clamp that makes truncation common in the first place is not touched here. Four PRs against that constant have been closed without merging; it is contested design territory and belongs in its own change. This PR is about not misreporting truncation when it happens.

How did you verify your code works?

Three integration tests in packages/opencode/test/session/prompt.test.ts, plus a length() finish on the test LLM server's reply builder. All three were written first and confirmed failing (0 pass / 3 fail) before any production edit.

  • Recoverable case: a length turn with text, followed by a stop turn — asserts exactly two provider requests and that the final message carries the completed text.
  • Reasoning-only case: asserts MessageOutputLengthError on both the returned and the persisted message, with the reasoning part preserved.
  • Bound: two consecutive length turns with a sentinel third reply queued — asserts exactly two provider requests, so an unbounded implementation fails an assertion rather than hanging.

Mutation-checked in both directions: reverting the production hunks turns all three red, restoring returns them green. A cross-family reviewer independently reverted each of the two hunks separately and confirmed the bound is what makes the third test pass — with the in-loop hunk alone reverted, the loop runs three provider turns instead of two.

bun test in packages/opencode: 3231 pass / 0 fail (baseline on dev is 3228). bun typecheck clean in packages/opencode and packages/core.

One honest limitation: the first test does not discriminate the in-loop hunk on its own — with that hunk reverted, SessionProcessor.process returns "continue" anyway and the observable outcome is identical. It documents end-to-end recovery; the in-loop logic is pinned by the other two tests.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

One related PR was found:

PR #26167: fix(session): retry empty stream truncations and discard partial parts

This PR appears related as it addresses truncation handling in sessions, specifically dealing with empty stream truncations and partial parts. While the scope differs from PR #40142 (which focuses on surfacing truncated turns by checking finishReason: "length" in the session loop), both PRs tackle the problem of how truncated model outputs are handled and recovered from in the session layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Turns truncated at the output limit are recorded as normal completions

1 participant